D:\a\cssh-rs\cssh-rs\xtask\src\social_preview.rs
Line | Count | Source |
1 | | //! Social preview image generation. |
2 | | //! |
3 | | //! Orchestrates `docker run` against the pinned Playwright image to render |
4 | | //! `templates/social-preview.html` into a 1280x640 PNG with live data |
5 | | //! fetched from the GitHub API. The Rust side is a thin shell: all HTTP, |
6 | | //! template substitution, and screenshotting live in |
7 | | //! `xtask/social-preview/generate.mjs`, which runs inside the container. |
8 | | //! |
9 | | //! The host only needs Rust, Cargo, and Docker. No host-side Node.js, npm, |
10 | | //! or Playwright installation is required. |
11 | | |
12 | | use std::path::{Path, PathBuf}; |
13 | | |
14 | | use anyhow::{bail, Context, Result}; |
15 | | |
16 | | /// Pinned Playwright Docker image tag. |
17 | | /// |
18 | | /// The numeric portion (e.g. `v1.59.1`) must match `@playwright/test` in |
19 | | /// `xtask/social-preview/package.json`. Playwright refuses to run when |
20 | | /// these versions diverge, so bump both in the same commit. See |
21 | | /// `xtask/social-preview/README.md` for details. |
22 | | const PLAYWRIGHT_IMAGE: &str = "mcr.microsoft.com/playwright:v1.59.1-noble"; |
23 | | |
24 | | /// Default output path for the generated PNG, relative to the workspace |
25 | | /// root. Lives under `target/` so it shares Cargo's build-artifact |
26 | | /// directory and inherits its `.gitignore` entry. |
27 | | const DEFAULT_OUT: &str = "target/social-preview/social-preview.png"; |
28 | | |
29 | | /// Container-side mount point for the workspace. |
30 | | const CONTAINER_WORKSPACE: &str = "/workspace"; |
31 | | |
32 | | /// All side-effecting operations required by this module. |
33 | | /// |
34 | | /// Implement with mocks in tests to achieve zero docker, filesystem, |
35 | | /// process, and network side-effects. |
36 | | pub trait SocialPreviewSystem { |
37 | | /// Return the absolute path to the workspace root (parent of `xtask/`). |
38 | | /// |
39 | | /// # Errors |
40 | | /// |
41 | | /// Returns an error if the workspace root cannot be resolved. |
42 | | fn workspace_root(&self) -> Result<PathBuf>; |
43 | | |
44 | | /// Read an environment variable, returning `None` when unset or empty. |
45 | | fn env_var(&self, key: &str) -> Option<String>; |
46 | | |
47 | | /// Ensure the parent directory of `path` exists, creating it (and any |
48 | | /// missing ancestors) if necessary. |
49 | | /// |
50 | | /// # Arguments |
51 | | /// |
52 | | /// * `path` - File path whose parent directory must exist. |
53 | | /// |
54 | | /// # Errors |
55 | | /// |
56 | | /// Returns an error if the directory cannot be created. |
57 | | fn ensure_parent_dir(&self, path: &Path) -> Result<()>; |
58 | | |
59 | | /// Verify that `docker` is installed on `PATH` and that its daemon is |
60 | | /// reachable. Called before any `docker run` invocation so the user |
61 | | /// gets a helpful message instead of a raw pipe/socket error. |
62 | | /// |
63 | | /// # Errors |
64 | | /// |
65 | | /// Returns an error describing whether the binary is missing or the |
66 | | /// daemon is not running. |
67 | | fn check_docker_ready(&self) -> Result<()>; |
68 | | |
69 | | /// Return `true` when `image` is already present in the local image |
70 | | /// cache (i.e. `docker image inspect <image>` succeeds). |
71 | | fn docker_image_exists(&self, image: &str) -> bool; |
72 | | |
73 | | /// Run `docker pull <image>` with inherited stdio so the user sees |
74 | | /// layer-download progress. |
75 | | /// |
76 | | /// # Errors |
77 | | /// |
78 | | /// Returns an error if `docker pull` exits with a non-zero status. |
79 | | fn docker_pull(&self, image: &str) -> Result<()>; |
80 | | |
81 | | /// Invoke `docker` with the given argument list and environment. |
82 | | /// |
83 | | /// # Arguments |
84 | | /// |
85 | | /// * `args` - Arguments passed to `docker` (starting with the |
86 | | /// subcommand, e.g. `run`). |
87 | | /// * `envs` - Additional `(key, value)` environment variables applied |
88 | | /// to the spawned `docker` process; these are forwarded to the |
89 | | /// container via explicit `-e` flags built into `args`. |
90 | | /// |
91 | | /// # Errors |
92 | | /// |
93 | | /// Returns an error if the process cannot be started or exits with a |
94 | | /// non-zero status. |
95 | | fn run_docker(&self, args: &[String], envs: &[(String, String)]) -> Result<()>; |
96 | | } |
97 | | |
98 | | /// Production implementation of [`SocialPreviewSystem`]. |
99 | | pub struct RealSystem; |
100 | | |
101 | | #[cfg_attr(coverage_nightly, coverage(off))] |
102 | | impl SocialPreviewSystem for RealSystem { |
103 | | fn workspace_root(&self) -> Result<PathBuf> { |
104 | | // CARGO_MANIFEST_DIR is set by Cargo when building this binary; it |
105 | | // points at xtask/, whose parent is the workspace root. |
106 | | let manifest_dir = env!("CARGO_MANIFEST_DIR"); |
107 | | let root = Path::new(manifest_dir) |
108 | | .parent() |
109 | | .context("failed to resolve workspace root from CARGO_MANIFEST_DIR")? |
110 | | .to_path_buf(); |
111 | | Ok(root) |
112 | | } |
113 | | |
114 | | fn env_var(&self, key: &str) -> Option<String> { |
115 | | std::env::var(key).ok().filter(|v| !v.is_empty()) |
116 | | } |
117 | | |
118 | | fn ensure_parent_dir(&self, path: &Path) -> Result<()> { |
119 | | if let Some(parent) = path.parent() { |
120 | | std::fs::create_dir_all(parent) |
121 | | .with_context(|| format!("failed to create directory {}", parent.display()))?; |
122 | | } |
123 | | Ok(()) |
124 | | } |
125 | | |
126 | | fn check_docker_ready(&self) -> Result<()> { |
127 | | // `docker info` is cheap and exercises both the CLI resolution |
128 | | // path and a round-trip to the daemon socket. |
129 | | let output = match std::process::Command::new("docker") |
130 | | .args(["info", "--format", "{{.ServerVersion}}"]) |
131 | | .output() |
132 | | { |
133 | | Ok(o) => o, |
134 | | Err(e) if e.kind() == std::io::ErrorKind::NotFound => { |
135 | | bail!( |
136 | | "`docker` was not found on PATH. Install Docker Desktop (or the Docker Engine) and ensure `docker` is on your PATH." |
137 | | ); |
138 | | } |
139 | | Err(e) => { |
140 | | return Err(e).context("failed to spawn `docker info`"); |
141 | | } |
142 | | }; |
143 | | if output.status.success() && !output.stdout.is_empty() { |
144 | | return Ok(()); |
145 | | } |
146 | | let stderr = String::from_utf8_lossy(&output.stderr); |
147 | | bail!( |
148 | | "Docker is installed but its daemon is not reachable. Start Docker Desktop (or your Docker daemon) and try again.\n docker info stderr: {}", |
149 | | stderr.trim() |
150 | | ); |
151 | | } |
152 | | |
153 | | fn docker_image_exists(&self, image: &str) -> bool { |
154 | | std::process::Command::new("docker") |
155 | | .args(["image", "inspect", image]) |
156 | | .stdout(std::process::Stdio::null()) |
157 | | .stderr(std::process::Stdio::null()) |
158 | | .status() |
159 | | .map(|s| s.success()) |
160 | | .unwrap_or(false) |
161 | | } |
162 | | |
163 | | fn docker_pull(&self, image: &str) -> Result<()> { |
164 | | let status = std::process::Command::new("docker") |
165 | | .args(["pull", image]) |
166 | | .status() |
167 | | .with_context(|| format!("failed to spawn `docker pull {image}`"))?; |
168 | | if !status.success() { |
169 | | bail!("`docker pull {image}` failed with status {status}"); |
170 | | } |
171 | | Ok(()) |
172 | | } |
173 | | |
174 | | fn run_docker(&self, args: &[String], envs: &[(String, String)]) -> Result<()> { |
175 | | let mut command = std::process::Command::new("docker"); |
176 | | command.args(args); |
177 | | for (key, value) in envs { |
178 | | command.env(key, value); |
179 | | } |
180 | | let status = command |
181 | | .status() |
182 | | .context("failed to spawn `docker`; is Docker installed and on PATH?")?; |
183 | | if !status.success() { |
184 | | bail!("`docker {}` failed with status {status}", args.join(" ")); |
185 | | } |
186 | | Ok(()) |
187 | | } |
188 | | } |
189 | | |
190 | | /// Split the caller-supplied `--out` into (host-absolute path, workspace- |
191 | | /// relative path with forward slashes). |
192 | | /// |
193 | | /// Accepts any path. Relative paths resolve against the workspace root; |
194 | | /// absolute paths are used as-is. Lexical `..` components are normalised |
195 | | /// so inputs like `sub/../preview.png` are supported. The final resolved |
196 | | /// path must still live under `workspace_root` so the container bind mount |
197 | | /// can reach it at `/workspace/<rel>`; paths outside the workspace are |
198 | | /// rejected with a clear error. |
199 | 11 | fn resolve_out_paths(workspace_root: &Path, out: Option<PathBuf>) -> Result<(PathBuf, String)> { |
200 | 11 | let raw = out.unwrap_or_else(|| PathBuf::from7 (DEFAULT_OUT)); |
201 | 11 | let joined = if raw.is_absolute() { |
202 | 1 | raw.clone() |
203 | | } else { |
204 | 10 | workspace_root.join(&raw) |
205 | | }; |
206 | 11 | let normalised = normalise_path(&joined); |
207 | 11 | let rel9 = normalised.strip_prefix(workspace_root).map_err(|_| {2 |
208 | 2 | anyhow::anyhow!( |
209 | | "--out must resolve to a path inside the workspace root ({}); got {}", |
210 | 2 | workspace_root.display(), |
211 | 2 | raw.display() |
212 | | ) |
213 | 2 | })?; |
214 | 9 | let rel_str = rel.to_string_lossy().replace('\\', "/"); |
215 | 9 | Ok((normalised.clone(), rel_str)) |
216 | 11 | } |
217 | | |
218 | | /// Lexically normalise a path by collapsing `.` and `..` components |
219 | | /// without touching the filesystem. Behaves like `Path::canonicalize` |
220 | | /// minus the requirement that the path exist. `..` at the root is |
221 | | /// dropped (matching POSIX semantics). |
222 | 11 | fn normalise_path(path: &Path) -> PathBuf { |
223 | | use std::path::Component; |
224 | 11 | let mut out = PathBuf::new(); |
225 | 45 | for comp in path11 .components11 () { |
226 | 45 | match comp { |
227 | | Component::ParentDir => { |
228 | | // Only pop if the last pushed component is a regular |
229 | | // segment; otherwise drop (root `..`) or keep (leading |
230 | | // `..` on a relative path). |
231 | 2 | let popped = match out.components().next_back() { |
232 | | Some(Component::Normal(_)) => { |
233 | 2 | out.pop(); |
234 | 2 | true |
235 | | } |
236 | 0 | _ => false, |
237 | | }; |
238 | 2 | if !popped && !path.is_absolute()0 { |
239 | 0 | out.push(".."); |
240 | 2 | } |
241 | | } |
242 | 0 | Component::CurDir => {} |
243 | 43 | other => out.push(other.as_os_str()), |
244 | | } |
245 | | } |
246 | 11 | out |
247 | 11 | } |
248 | | |
249 | | /// Render a `docker` argument list as a single shell-quoted string, purely |
250 | | /// for diagnostic logging. Arguments containing whitespace or shell |
251 | | /// metacharacters are wrapped in single quotes; inner single quotes are |
252 | | /// escaped as `'\''`. This is never re-parsed - it's only printed to |
253 | | /// stdout so a user can copy-paste the exact invocation. |
254 | 8 | fn shell_quote_args(args: &[String]) -> String { |
255 | 8 | args.iter() |
256 | 100 | .map8 (|a| { |
257 | 100 | if a.is_empty() |
258 | 1.16k | || a.chars()100 .any100 (|c| { |
259 | 1.16k | c.is_whitespace() |
260 | 1.15k | || matches!( |
261 | 1.16k | c, |
262 | | '\'' | '"' |
263 | | | '$' |
264 | | | '`' |
265 | | | '\\' |
266 | | | '&' |
267 | | | '|' |
268 | | | ';' |
269 | | | '<' |
270 | | | '>' |
271 | | | '(' |
272 | | | ')' |
273 | | | '{' |
274 | | | '}' |
275 | | | '*' |
276 | | | '?' |
277 | | | '#' |
278 | | | '!' |
279 | | | '[' |
280 | | | ']' |
281 | | ) |
282 | 1.16k | }) |
283 | | { |
284 | 8 | format!("'{}'", a.replace('\'', "'\\''")) |
285 | | } else { |
286 | 92 | a.clone() |
287 | | } |
288 | 100 | }) |
289 | 8 | .collect::<Vec<_>>() |
290 | 8 | .join(" ") |
291 | 8 | } |
292 | | |
293 | | /// Build the `docker run` argument list for the generator script. |
294 | 8 | fn build_docker_args(workspace_root: &Path, container_out: &str, has_token: bool) -> Vec<String> { |
295 | 8 | let mount = format!( |
296 | | "{}:{CONTAINER_WORKSPACE}", |
297 | 8 | workspace_root.to_string_lossy().replace('\\', "/") |
298 | | ); |
299 | 8 | let mut args: Vec<String> = vec![ |
300 | 8 | "run".into(), |
301 | 8 | "--rm".into(), |
302 | 8 | "-v".into(), |
303 | 8 | mount, |
304 | 8 | "-w".into(), |
305 | 8 | CONTAINER_WORKSPACE.into(), |
306 | 8 | "-e".into(), |
307 | 8 | format!("OUT_PATH={container_out}"), |
308 | | ]; |
309 | 8 | if has_token { |
310 | 2 | args.push("-e".into()); |
311 | 2 | args.push("GITHUB_TOKEN".into()); |
312 | 6 | } |
313 | 8 | args.push(PLAYWRIGHT_IMAGE.into()); |
314 | 8 | args.push("sh".into()); |
315 | 8 | args.push("-c".into()); |
316 | | // Install node_modules on first run, then invoke the generator. We |
317 | | // use `npm ci` (not `npm install`) so the install is strictly driven |
318 | | // by the committed `package-lock.json`; this keeps runs reproducible |
319 | | // and prevents the bind-mounted workspace from picking up lockfile |
320 | | // mutations. Subsequent runs skip the install entirely and stay |
321 | | // offline. |
322 | | // |
323 | | // The install runs in a subshell so it does not alter the CWD of the |
324 | | // subsequent `node` invocation. `generate.mjs` resolves its inputs |
325 | | // (template, logo, font, linguist colors) as workspace-relative paths, |
326 | | // so it must run from `/workspace` - not from |
327 | | // `/workspace/xtask/social-preview`. |
328 | 8 | args.push( |
329 | 8 | "( cd xtask/social-preview && { [ -d node_modules ] || npm ci; } ) && node xtask/social-preview/generate.mjs" |
330 | 8 | .into(), |
331 | | ); |
332 | 8 | args |
333 | 8 | } |
334 | | |
335 | | /// Generate the social preview PNG. |
336 | | /// |
337 | | /// Resolves the output path, ensures the host-side output directory |
338 | | /// exists, and invokes the Playwright Docker container which runs |
339 | | /// `xtask/social-preview/generate.mjs` to fetch live GitHub data and |
340 | | /// render `templates/social-preview.html` to PNG. |
341 | | /// |
342 | | /// # Arguments |
343 | | /// |
344 | | /// * `system` - Injected I/O provider. |
345 | | /// * `out` - Optional output path override. Relative paths resolve against |
346 | | /// the workspace root. |
347 | | /// * `token` - Optional GitHub token override. Falls back to the |
348 | | /// `GITHUB_TOKEN` environment variable, then unauthenticated access. |
349 | | /// |
350 | | /// # Errors |
351 | | /// |
352 | | /// Returns an error if the workspace root cannot be resolved, the output |
353 | | /// directory cannot be created, or the `docker run` invocation fails. |
354 | 11 | pub fn generate_social_preview<S: SocialPreviewSystem>( |
355 | 11 | system: &S, |
356 | 11 | out: Option<PathBuf>, |
357 | 11 | token: Option<String>, |
358 | 11 | ) -> Result<()> { |
359 | 11 | let workspace_root = system.workspace_root()?0 ; |
360 | 11 | let (host_out9 , relative_out9 ) = resolve_out_paths(&workspace_root, out)?2 ; |
361 | 9 | log::info!("Generating social preview -> {}", host_out.display()); |
362 | 9 | system.check_docker_ready()?1 ; |
363 | 8 | if !system.docker_image_exists(PLAYWRIGHT_IMAGE) { |
364 | 1 | log::info!("Pulling Playwright image {PLAYWRIGHT_IMAGE} (first run only)"); |
365 | 1 | system.docker_pull(PLAYWRIGHT_IMAGE)?0 ; |
366 | 7 | } |
367 | 8 | system.ensure_parent_dir(&host_out)?0 ; |
368 | | |
369 | 8 | let container_out = format!("{CONTAINER_WORKSPACE}/{relative_out}"); |
370 | 8 | let resolved_token = token.or_else(|| system7 .env_var7 ("GITHUB_TOKEN"7 )); |
371 | 8 | let has_token = resolved_token.is_some(); |
372 | | |
373 | 8 | let args = build_docker_args(&workspace_root, &container_out, has_token); |
374 | 8 | let envs: Vec<(String, String)> = resolved_token |
375 | 8 | .into_iter() |
376 | 8 | .map(|t| ("GITHUB_TOKEN"2 .to_owned2 (), t2 )) |
377 | 8 | .collect(); |
378 | | |
379 | 8 | log::info!("Starting Playwright container {PLAYWRIGHT_IMAGE}"); |
380 | 8 | log::debug!("+ docker {}", shell_quote_args(&args)); |
381 | 8 | system.run_docker(&args, &envs)?1 ; |
382 | 7 | log::info!("Wrote {}", host_out.display()); |
383 | 7 | Ok(()) |
384 | 11 | } |
385 | | |
386 | | #[cfg(test)] |
387 | | #[path = "tests/test_social_preview.rs"] |
388 | | mod tests; |